Execute paged queries on the driver instead of blocking a thread - #771
Merged
Conversation
PagedList<T>.Create was async in name only. InitializeAsync ran Count() and enumerated the IQueryable synchronously and returned Task.CompletedTask, so every paged listing - admin grids, catalog listings, search - held a thread pool thread for two round trips to the database. The same defect existed in the second form, new PagedList<T>(query, ...) wrapped in Task.FromResult, where the constructor executed the query just as silently. Query execution now lives with the provider rather than in the domain project. IRepository<T> gains ToListAsync, CountAsync and PagedAsync; MongoRepository runs them on the driver's asynchronous API and LiteDB keeps a synchronous implementation, which is honest there because it is an embedded database with no async API. PagedList is left as a plain result type and no longer touches IQueryable, so Grand.Domain no longer reaches the database. The repository deliberately does not guard against being handed a sequence that is already in memory. Quietly enumerating it would restore the very pattern this removes, so the driver's own ArgumentException is allowed through. MongoQueryableContractTests pins the assumption this relies on - that filters, projections, groupings and flattened sub-collections all stay driver queries. Two service methods that were synchronous while doing blocking database work became asynchronous as a consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MSTEST0044: DataTestMethod is obsolete, a parametrised test is declared with TestMethod and the data source attributes. The new paged query tests also dropped DynamicData in favour of DataRow, which is what the rest of the test suite uses and reads better than a method returning provider names as strings. The occurrence in CustomerServiceTests predates this branch and is fixed here so the build stops reporting the warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KrzysztofPajak
added a commit
that referenced
this pull request
Aug 10, 2026
GetAllPages returned Task.FromResult(query.ToList()) from a cache acquire lambda. Table is an IQueryable over the MongoDB driver, so ToList is a blocking round trip dressed up as async, and blocking there is worse than elsewhere because the cache holds a lock for the duration of the acquire. Same treatment #771 gave the paged queries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: bugfix
Issue
PagedList<T>.Createwas asynchronous in name only.InitializeAsynccalledCount()and enumerated theIQueryablesynchronously, then returnedTask.CompletedTask:Every paged listing in the application went through it — admin grids, storefront catalog listings, search — so each one held a thread pool thread for the whole duration of two database round trips. Under concurrency the thread pool becomes the constraint and has to inject threads at roughly one or two per second, which shows up as request queueing far away from the code that caused it.
The same defect existed in a second, quieter form:
new PagedList<T>(query, pageIndex, pageSize)wrapped inTask.FromResult. The constructor takesIEnumerable<T>, so anIQueryablebound to it happily and executed exactly the same way, with nothing in the signature to suggest a database call was happening.Reproduce by putting a breakpoint on any
IPagedListreturning service method and observing that the driver call completes on the calling thread with no await in between.Solution
Query execution moved out of the domain project and down to the provider.
IRepository<T>gainsToListAsync,CountAsyncandPagedAsync. All three are generic over the result type so projections are covered, not just entities.MongoRepository<T>implements them on the driver's asynchronous API (MongoQueryable.ToListAsync/CountAsync).LiteDBRepository<T>implements them synchronously. LiteDB is an embedded database with no asynchronous API and itsTablealready materialises the collection, soTask.FromResultis accurate there rather than misleading. It is commented as such.PagedList<T>losesInitializeAsyncandCreate. It is now a plain result type built from data someone else fetched, andGrand.Domainno longer reaches the database.src/Businessand in two plugins.The repository intentionally does not check whether the query it was given is still a driver query. Falling back to enumerating an in-memory sequence would silently restore the blocking behaviour this change removes, so the driver's own
ArgumentException: The source argument must be a MongoDB IQueryableis allowed through instead.MongoQueryableContractTestspins the assumption that makes this safe: filters, sorting, paging, scalar projections, groupings to anonymous types, flattened sub-collections and filters against a captured list all remain driver queries. If a driver upgrade breaks one of those, a test fails rather than a service quietly loading a whole collection into memory..ai/knowledge/async.mdcarried a rule that legitimised the pattern being removed — it stated that materialising aTablequery synchronously "is fine ... because Table is IQueryable against MongoDB, not EF". That is not true and it is where the pattern kept coming from, so the rule was rewritten.Breaking changes
Three public contract changes. None affect view models, widget zones or plugin system names.
IRepository<T>gains three members. There is no implementation of it outsideGrand.Datain this repository, but an external plugin supplying its own implementation would no longer compile.IPictureService.GetPicturesreturnsTask<IPagedList<Picture>>instead ofIPagedList<Picture>. It was synchronous while performing a blocking database query.ICustomerReportService.GetBestCustomersReportreturnsTask<IPagedList<BestCustomerReportLine>>for the same reason.Both service methods had two call sites each, all already inside asynchronous methods.
Testing
dotnet build ./GrandNode.sln— succeeds.Start a local MongoDB on
mongodb://localhost(the data test fixtures use a real instance), then run the affected test projects:dotnet test src/Tests/Grand.Data.Tests/Grand.Data.Tests.csproj— 72 tests, includes the newRepositoryPagedQueryTestsandMongoQueryableContractTests.dotnet test src/Tests/Grand.Domain.Tests/Grand.Domain.Tests.csprojsrc/Tests/Grand.Business.*.Testsprojects.Grand.Web.Admin.Tests,Grand.Web.Tests,Grand.Web.Store.Tests,Grand.Module.Api.Tests,Grand.Infrastructure.Tests,Grand.Web.Common.Tests.Run these per project rather than across the whole solution — the full parallel run has known flakes unrelated to this change.
RepositoryPagedQueryTestsruns every case against both MongoDB and LiteDB: first page, last partial page, a page beyond the range, no matches,pageSize <= 0normalisation, and a projection. It assertsTotalCount,TotalPages,HasPreviousPageandHasNextPage, which is what would break silently if the paging arithmetic drifted.Start the storefront on Kestrel (
dotnet run --launch-profile Kestrelfromsrc/Web/Grand.Web) and walk pages that go through the converted path: a category listing and its second page,/searchwith a query and withpagenumber=2,/newproducts,/blog. Confirm the result counts and page numbering are unchanged and nothing is logged as failed.In the admin area, page and sort a product grid, then open Settings and the picture list under Maintenance — those exercise the two service methods whose signatures changed.
🤖 Generated with Claude Code